Просмотр исходного кода

Fix Tidepool auth state in service view
- New TidepoolHealth enum with healthPublisher on TidepoolManager
- All 7 upload-completions in TidepoolManager now record success/failure
- TidepoolStartView subscribes via .onReceive and switches the connection indicator

Deniz Cengiz 1 день назад
Родитель
Сommit
673f0e707b

+ 6 - 0
Trio/Sources/Localizations/Main/Localizable.xcstrings

@@ -258318,6 +258318,9 @@
         }
       }
     },
+    "Tidepool Auth Error — tap to re-login" : {
+
+    },
     "Tidepool Integration" : {
       "localizations" : {
         "bg" : {
@@ -258442,6 +258445,9 @@
         }
       }
     },
+    "Tidepool Sync Error" : {
+
+    },
     "Time" : {
       "comment" : "Time basal profile",
       "localizations" : {

+ 51 - 3
Trio/Sources/Modules/Settings/View/TidepoolStartView.swift

@@ -11,6 +11,11 @@ struct TidepoolStartView: BaseView {
     @State private var decimalPlaceholder: Decimal = 0.0
     @State private var booleanPlaceholder: Bool = false
 
+    /// Mirror of `TidepoolManager.healthPublisher`. Drives the connection
+    /// indicator below — `getTidepoolServiceUI() != nil` only proves the
+    /// service was once configured, not that it's still authenticated.
+    @State private var tidepoolHealth: TidepoolHealth = .unknown
+
     @Environment(\.colorScheme) var colorScheme
     @Environment(AppState.self) var appState
 
@@ -31,11 +36,11 @@ struct TidepoolStartView: BaseView {
                                 }
                                 label: {
                                     HStack {
-                                        Text("Connected to Tidepool").font(.title3)
+                                        Text(connectionLabel).font(.title3)
                                         ZStack {
                                             Image(systemName: "network")
-                                            Image(systemName: "checkmark.circle.fill")
-                                                .foregroundColor(.green).font(.caption2)
+                                            Image(systemName: connectionIconName)
+                                                .foregroundColor(connectionIconColor).font(.caption2)
                                                 .offset(x: 9, y: 6)
                                         }
                                     }
@@ -108,5 +113,48 @@ struct TidepoolStartView: BaseView {
         .navigationTitle("Tidepool")
         .navigationBarTitleDisplayMode(.automatic)
         .onAppear(perform: configureView)
+        .onReceive(state.provider.tidepoolManager.healthPublisher) { newHealth in
+            tidepoolHealth = newHealth
+        }
+    }
+
+    /// Label shown next to the network icon when a Tidepool service is configured.
+    /// Auth failures get an explicit re-login prompt; transient hiccups are
+    /// surfaced but kept gentler so a flaky network doesn't alarm the user.
+    private var connectionLabel: String {
+        switch tidepoolHealth {
+        case .healthy,
+             .unknown:
+            return String(localized: "Connected to Tidepool")
+        case .authFailed:
+            return String(localized: "Tidepool Auth Error — tap to re-login")
+        case .transient:
+            return String(localized: "Tidepool Sync Error")
+        }
+    }
+
+    /// SF Symbol for the small status dot overlaid on the network icon.
+    private var connectionIconName: String {
+        switch tidepoolHealth {
+        case .healthy,
+             .unknown:
+            return "checkmark.circle.fill"
+        case .authFailed:
+            return "xmark.circle.fill"
+        case .transient:
+            return "exclamationmark.circle.fill"
+        }
+    }
+
+    private var connectionIconColor: Color {
+        switch tidepoolHealth {
+        case .healthy,
+             .unknown:
+            return .green
+        case .authFailed:
+            return .red
+        case .transient:
+            return .orange
+        }
     }
 }

+ 89 - 0
Trio/Sources/Services/Network/TidepoolManager.swift

@@ -9,6 +9,26 @@ import Swinject
 import TidepoolServiceKit
 import UIKit
 
+/// Observed state of the Tidepool integration, derived from upload outcomes to Tidepool.
+///
+/// `getTidepoolServiceUI() != nil` only tells the UI that a service object
+/// is configured locally — it doesn't say whether the credentials are still
+/// accepted by Tidepool. This enum is the truth source for the "connected"
+/// indicator in `TidepoolStartView`. It's updated on every upload success /
+/// failure callback (carbs, insulin, glucose, settings, deletes).
+enum TidepoolHealth: Equatable {
+    /// No upload attempt yet this process — show optimistic UI.
+    case unknown
+    /// Most recent upload returned 2xx at the given time.
+    case healthy(at: Date)
+    /// Refresh-token grant rejected or API token explicitly refused (401/403).
+    /// User needs to re-authenticate via the existing Tidepool settings sheet.
+    case authFailed(at: Date)
+    /// Any other failure (5xx, network blip, decode error, other 4xx).
+    /// Self-clears on the next successful upload.
+    case transient(at: Date)
+}
+
 protocol TidepoolManager {
     func addTidepoolService(service: Service)
     func getTidepoolServiceUI() -> ServiceUI?
@@ -20,6 +40,9 @@ protocol TidepoolManager {
     func uploadGlucose() async
     func uploadSettings() async
     func forceTidepoolDataUpload()
+    /// Live updates whenever an upload returns; backed by a `CurrentValueSubject`
+    /// so subscribers receive the current value on subscribe.
+    var healthPublisher: AnyPublisher<TidepoolHealth, Never> { get }
 }
 
 final class BaseTidepoolManager: TidepoolManager, Injectable {
@@ -68,6 +91,15 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
 
     @PersistedProperty(key: "TidepoolState") var rawTidepoolManager: Service.RawValue?
 
+    /// Backing storage for `healthPublisher`. Seeded with `.unknown` so the UI
+    /// shows the optimistic "connected" indicator until the first upload
+    /// returns. Mutated only via `noteUploadSuccess` / `noteUploadFailure`.
+    private let healthSubject = CurrentValueSubject<TidepoolHealth, Never>(.unknown)
+
+    var healthPublisher: AnyPublisher<TidepoolHealth, Never> {
+        healthSubject.eraseToAnyPublisher()
+    }
+
     init(resolver: Resolver) {
         self.resolver = resolver
         injectServices(resolver)
@@ -164,6 +196,49 @@ final class BaseTidepoolManager: TidepoolManager, Injectable {
         nil
     }
 
+    // MARK: - Upload health tracking
+
+    /// Records a successful Tidepool upload. Resets `health` to `.healthy(now)`
+    /// — also clears any prior `.authFailed` / `.transient` state, so the UI
+    /// returns to the optimistic indicator on the next success.
+    fileprivate func noteUploadSuccess() {
+        healthSubject.send(.healthy(at: Date()))
+    }
+
+    /// Records a failed Tidepool upload. Routes through `classify` to decide
+    /// whether the failure is auth-related (needs user re-login) or just a
+    /// transient network/server hiccup. Called from every `.failure` branch
+    /// in the upload completion handlers.
+    fileprivate func noteUploadFailure(_ error: Error) {
+        healthSubject.send(classify(error))
+    }
+
+    /// Best-effort classification of a Tidepool upload error.
+    ///
+    /// The Tidepool plugin doesn't expose a typed error case for "refresh
+    /// token rejected" — its `responseMalformedJSON` wraps the HTTP response
+    /// inside the error, surfaced via `String(describing:)`. We inspect that
+    /// string for the OAuth host + HTTP status to distinguish:
+    ///   - 4xx on `auth.tidepool.org` → refresh-token grant rejected;
+    ///     credentials are no longer valid and the user must re-authenticate.
+    ///   - 401 / 403 on any other endpoint → access token rejected by the
+    ///     API itself; also treated as `authFailed`.
+    ///   - Everything else (5xx, non-auth 4xx like 429, network errors,
+    ///     decoding errors) → `transient`; clears on the next success.
+    fileprivate func classify(_ error: Error) -> TidepoolHealth {
+        let desc = String(describing: error)
+        let isAuthHost = desc.contains("auth.tidepool.org")
+
+        // Cheap substring scan — failure path runs rarely, ~100 lookups OK.
+        let authHostHas4xx = (400 ..< 500).contains { desc.contains("Status Code: \($0)") }
+        let tokenRejected = desc.contains("Status Code: 401") || desc.contains("Status Code: 403")
+
+        if (isAuthHost && authHostHas4xx) || tokenRejected {
+            return .authFailed(at: Date())
+        }
+        return .transient(at: Date())
+    }
+
     /// Forces a full data upload to Tidepool
     func forceTidepoolDataUpload() {
         Task {
@@ -233,8 +308,10 @@ extension BaseTidepoolManager {
                     switch result {
                     case let .failure(error):
                         debug(.nightscout, "Error synchronizing carbs data with Tidepool: \(String(describing: error))")
+                        self.noteUploadFailure(error)
                     case .success:
                         debug(.nightscout, "Success synchronizing carbs data. Upload to Tidepool complete.")
+                        self.noteUploadSuccess()
                         // After successful upload, update the isUploadedToTidepool flag in Core Data
                         Task {
                             await self.updateCarbsAsUploaded(carbs)
@@ -293,8 +370,10 @@ extension BaseTidepoolManager {
                 switch result {
                 case let .failure(error):
                     debug(.nightscout, "Error synchronizing carbs data with Tidepool: \(String(describing: error))")
+                    self.noteUploadFailure(error)
                 case .success:
                     debug(.nightscout, "Success synchronizing carbs data. Upload to Tidepool complete.")
+                    self.noteUploadSuccess()
                 }
             }
         }
@@ -399,8 +478,10 @@ extension BaseTidepoolManager {
                         switch result {
                         case let .failure(error):
                             debug(.nightscout, "Error synchronizing dose data with Tidepool: \(String(describing: error))")
+                            self.noteUploadFailure(error)
                         case .success:
                             debug(.nightscout, "Success synchronizing dose data. Upload to Tidepool complete.")
+                            self.noteUploadSuccess()
                             Task {
                                 let insulinEvents = events.filter {
                                     $0.type == .tempBasal || $0.type == .tempBasalDuration || $0.type == .bolus
@@ -414,8 +495,10 @@ extension BaseTidepoolManager {
                         switch result {
                         case let .failure(error):
                             debug(.nightscout, "Error synchronizing pump events data: \(String(describing: error))")
+                            self.noteUploadFailure(error)
                         case .success:
                             debug(.nightscout, "Success synchronizing pump events data. Upload to Tidepool complete.")
+                            self.noteUploadSuccess()
                             Task {
                                 let pumpEventType = events.map { $0.type.mapEventTypeToPumpEventType() }
                                 let pumpEvents = events.filter { _ in pumpEventType.contains(pumpEventType) }
@@ -470,8 +553,10 @@ extension BaseTidepoolManager {
                 switch result {
                 case let .failure(error):
                     debug(.nightscout, "Error synchronizing Dose delete data: \(String(describing: error))")
+                    self.noteUploadFailure(error)
                 case .success:
                     debug(.nightscout, "Success synchronizing Dose delete data")
+                    self.noteUploadSuccess()
                 }
             }
         }
@@ -637,6 +722,7 @@ extension BaseTidepoolManager {
                     switch result {
                     case .success:
                         debug(.nightscout, "Success synchronizing glucose data")
+                        self.noteUploadSuccess()
 
                         // After successful upload, update the isUploadedToTidepool flag in Core Data
                         Task {
@@ -644,6 +730,7 @@ extension BaseTidepoolManager {
                         }
                     case let .failure(error):
                         debug(.nightscout, "Error synchronizing glucose data: \(String(describing: error))")
+                        self.noteUploadFailure(error)
                     }
                 }
             }
@@ -715,8 +802,10 @@ extension BaseTidepoolManager {
                 switch result {
                 case .success:
                     debug(.service, "Settings uploaded to Tidepool (syncId: \(settings.syncIdentifier))")
+                    self.noteUploadSuccess()
                 case let .failure(error):
                     debug(.service, "Failed to upload settings to Tidepool: \(error)")
+                    self.noteUploadFailure(error)
                 }
             }
         }